240711 Jobs+Burst在风场粒子特效中的实践

准备一个简单的风场粒子材质

编写hlsl

新建WindFunc.hlsl脚本。由于Unity中并没有新建hlsl脚本选项,可以随意新建一个Shader脚本,改名为WindFunc.hlsl,然后将其中内容替换为:

#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)
            StructuredBuffer<float4x4> _Matrices;
#endif

void ConfigureProcedual()
{
#if defined(UNITY_PROCEDURAL_INSTANCING_ENABLED)
                unity_ObjectToWorld=_Matrices[unity_InstanceID];
#endif
}

void WindFunc_float(float3 In, out float3 Out)
{
    Out = In;
}
void WindFunc_half(half3 In, out half3 Out)
{
    Out = In;
}

使用ShaderGraph

在Project中新建Shader Graph/URP/Unlit Shader Graph,命名为WindGraph。

在WindGraph面板中单击右键Create Node/Input/Geometry/Position,创建一个Position结点,将Space由World改为Object。

在WindGraph面板中单击右键Create Node/Utility/Custom Function,创建一个Custom Function结点,选中它,在Graph Inspector面板中将Source替换为WindFunc,在Name栏中填写WindFunc,然后分别点击Inputs、Outputs下方的“+”,在下拉框中找到Vector3。

graph inspector 1

点击WindGraph面板的“+”,选择Float,命名为Smoothness,选中它,在Graph Inspector面板中将Mode由Default改为Slider,然后再次点击WindGraph面板的“+”,选择Color,命名为Base Color,选中它,在Graph Inspector面板中将Mode由Default改为HDR。

graph inspector 2

右键单击Fragment,Add Block Node/Smoothness,按下图方式连接,点击左上角的Save Asset。

graph

创建材质

在Project中新建Material,命名为WindParticleMat,选中它,在Inspector面板中将Shader更改为Shader Graphs/WindGraph,将Surface Inputs中的属性更改为你喜欢的样子,勾选Enable GPU Instancing,我们的材质就准备好了!

inspector

制作一个简单的风场粒子特效

编写C#脚本

新建WindParticle.cs脚本,删除其中的Start与Update方法。

在编辑器中,我们想要设置粒子的生成时间间隔、运动速度以及形状,指定粒子的网格和材质,故定义:

[SerializeField] private float interval = .2f;
[SerializeField] private float velocity = 1f;
[SerializeField] private Vector2 shape = new Vector2(2, .1f);
[SerializeField] private Mesh mesh;
[SerializeField] private Material mat;

对于一个粒子,我们需要知道它的生命周期与运动方向,故定义:

private float lifespan;
private Vector2 orient;

同时定义OnEnable方法,在其中初始化:

private void OnEnable(){
    lifespan = transform.localScale.x / velocity;
    orient = new Vector2(
        Mathf.Cos(transform.rotation.eulerAngles.z * Mathf.Deg2Rad),
        Mathf.Sin(transform.rotation.eulerAngles.z * Mathf.Deg2Rad)).normalized;
}

对于风场中的所有粒子,我们需要知道它们的位置、形状和数量,想要绘制这些粒子,我们又需要包含它们位置、旋转和形状信息的矩阵,故定义:

private int cnt;
private Vector3[] pos;
private Vector3[] scales;
private Matrix4x4[] matrices;

并在OnEnable方法中初始化:

cnt = Mathf.CeilToInt(lifespan / interval);
pos = new Vector3[cnt];
scales = new Vector3[cnt];
matrices = new Matrix4x4[cnt];
for (int i = 0; i < cnt; i++) scales[i] = Vector3.zero;

在粒子的初始化阶段,我们需要得知每一个粒子的初始化时机,还有当前正在初始化的粒子序号,粒子的初始化需要一个包含风场的位置、旋转和形状信息的矩阵,故定义:

private int idx;
private float timer;
private Matrix4x4 windMatrix;

并在OnEnable方法中初始化:

idx = 0;
timer = 0;
windMatrix = Matrix4x4.TRS
    (transform.position, transform.rotation, transform.localScale);

然后定义InitParticle方法,用于初始化单个粒子:

private void InitParticle()
{
    pos[idx] = windMatrix.MultiplyPoint(Vector3.left / 2 +
        Vector3.Lerp(Vector3.up, Vector3.down, UnityEngine.Random.Range(.25f, .75f)));
    scales[idx] = shape * UnityEngine.Random.Range(.25f, 1);
}

再定义OnFixedUpdate方法,在其中定时调用InitParticle方法:

private void FixedUpdate()
{
    timer += Time.fixedDeltaTime;
    if (timer > interval)
    {
        timer = 0;
        InitParticle();
        idx = (cnt + idx + 1) % cnt;
    }
}

接下来需要定义Update方法,实时计算粒子的位置,绘制在屏幕上:

private void Update()
 {
     for (int i = 0; i < cnt; ++i)
     {
         pos[i] += Time.deltaTime * velocity * (Vector3)orient;
         matrices[i] = Matrix4x4.TRS(pos[i], transform.rotation, scales[i]);
         Graphics.DrawMesh(mesh, matrices[i], mat, 0);
     }
 }

创建预制体

保存脚本,新建Prefab,命名为WindParticle,将这个脚本添加上去,指定Mesh为Sphere,Mat为WindParticleMat,设置至少看起来合理的数值,再添加BoxCollider2D组件(留作实现风场用,也能省去Gizmos标识范围的功夫),勾选IsTrigger,一个简单的风场粒子特效就制作好了!

prefab

预览

请我的策划朋友搭了个关卡,效果如下:

prev1

“能跑,”我的策划朋友说,“就是有点卡。”这究竟是怎么回事呢?

性能

进入Play Mode,在Game视窗中点击右上角的Stats,可以看到当前的帧率等信息:

stats1

可以看到,帧率竟然在24上下浮动,对于一部游戏来说十分甚至九分的糟糕。看来我的策划朋友讲话还是客气了点。如果我们并不刻意追求“电影感”,就得着手优化了。

优化这个简单的风场粒子特效

减少Draw Call

注意到这么几个粒子处理批次(Batches)竟高达161,我们首先应当减少绘制指令。

我们需要一个ComputeBuffer与材质中的StructuredBuffer对应,并且需要找到这个StructuredBuffer,故定义:

private ComputeBuffer buffer;
private static readonly int matricesID = Shader.PropertyToID("_Matrices");

由于我们需要cnt个缓冲区,每个缓冲区都是一个float4x4的大小,所以在OnEnable方法中初始化:

buffer = new ComputeBuffer(cnt, 64);

buffer应当及时清理,所以定义OnDisable方法并释放空间:

private void OnDisable()
{
    buffer?.Release();
    buffer = null;
}

在Update方法中找到:

Graphics.DrawMesh(mesh, matrices[i], mat, 0);

将其删除,在for循环外添加以下语句:

buffer.SetData(matrices);
mat.SetBuffer(matricesID, buffer);
Graphics.DrawMeshInstanced(mesh, mesh.subMeshCount - 1, mat, matrices);

保存脚本,让我们看看接下来会发生什么:

stats2

Batches瞬间下降至14,而帧率跃升到136左右!

使用Jobs替代for循环

Jobs不支持简单的数组,取而代之的是NativeArray,我们需要将for循环中用到的数组全部替换成NativeArray:

//private Vector3[] pos;
//private Vector3[] scales;
//private Matrix4x4[] matrices;
private NativeArray<Vector3> pos;
private NativeArray<Vector3> scales;
private NativeArray<Matrix4x4> matrices;

修改OnEnable中的初始化:

//pos = new Vector3[cnt];
//scales = new Vector3[cnt];
//matrices = new Matrix4x4[cnt];
pos = new NativeArray<Vector3>(cnt, Allocator.Persistent);
scales = new NativeArray<Vector3>(cnt, Allocator.Persistent);
matrices = new NativeArray<Matrix4x4>(cnt, Allocator.Persistent);

NativeArray需要及时释放空间,故应在OnDisable中添加:

pos.Dispose();
scales.Dispose();
matrices.Dispose();

在Update中修改Graphics.DrawMeshInstanced方法中的参数:

//Graphics.DrawMeshInstanced(mesh, mesh.subMeshCount - 1, mat, matrices);
Graphics.DrawMeshInstanced(mesh, mesh.subMeshCount - 1, mat, matrices.ToArray());

定义继承自IJobFor的结构体:

public struct Move : IJobFor
{
    public Vector2 orient;
    public float velocity;
    public float deltaTime;
    public Quaternion rotation;
    public NativeArray<Vector3> pos;
    [ReadOnly]
    public NativeArray<Vector3> scales;
    [WriteOnly]
    public NativeArray<Matrix4x4> matrices;
    public void Execute(int i)
    {
        pos[i] += deltaTime * velocity * (Vector3)orient;
        matrices[i] = Matrix4x4.TRS(pos[i], rotation, scales[i]);
    }
}

在Update中使用Jobs替换for循环:

//for (int i = 0; i < cnt; ++i)
//{
//    pos[i] += Time.deltaTime * velocity * (Vector3)orient;
//    matrices[i] = Matrix4x4.TRS(pos[i], transform.rotation, scales[i]);
//}
new Move()
{
    orient = orient,
    velocity = velocity,
    deltaTime = Time.deltaTime,
    rotation = transform.rotation,
    pos = pos,
    scales = scales,
    matrices = matrices,
}.Schedule(cnt, default).Complete();

此时的帧率达到了155左右!

stats3

使用Burst编译

在Move的定义前添加:

[BurstCompile(CompileSynchronously = true)]

此时的帧率达到了160以上!

stats4


参考

  1. https://catlikecoding.com/unity/tutorials/basics/jobs/
  2. https://blog.csdn.net/u014746275/article/details/132159968